Skip to content

Performance pass: Tiers A/B/C (visual-safe)#330

Merged
vide merged 4 commits into
mainfrom
perf/tiers-a-b-c
Jul 4, 2026
Merged

Performance pass: Tiers A/B/C (visual-safe)#330
vide merged 4 commits into
mainfrom
perf/tiers-a-b-c

Conversation

@vide

@vide vide commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Runtime performance work from the perf audit — Tiers A, B, C, all visual-safe. No pixels change (the one exception is a deliberate cost thousands-separator carried over from the prior PR's CHANGELOG entry). Build was already modern (Kotlin 2.3.20 strong-skipping, release minify/shrink), so these target hot paths.

Tier A — tiny change, big gain

  • collectAsStatecollectAsStateWithLifecycle app-wide (21 screens): stop recomposing while backgrounded/covered.
  • Dashboard stops its 5s status poll when off screen (LifecycleStartEffect + resume/pause) — no off-screen network/CPU/battery.
  • StatsRepository.getStats runs off Dispatchers.IO and fans its 3 independent groups out with async instead of ~50 sequential DAO queries on the main thread.
  • contentType on the charges/drives/trips/mileage lists — row-node reuse during fling.
  • ChargingNotificationWorker no longer refetches the full car list once per car.

Tier B — per-frame / allocation wins

  • Hoisted chart Paints in ChartDrawUtils (built once via remember, not per draw) — the 800ms entrance animation redraws ~48 frames, so this removes thousands of transient Paint/Color/String allocations. Same pixels.
  • PowerSocOverlayChart pre-sorts each curve's points once instead of re-sorting inside the draw loop and twice per crosshair-drag frame.
  • Mileage memoizes its chart-data derivation + BarChartData mapping; BarChartData/BarSegment marked @Immutable.

Tier C — data layer

  • Comparison repos (findComparable) move the full-history load + per-row Haversine + sort to Dispatchers.Default (off the main thread).
  • TripRepository.getTrips runs off-main, and the beta-era SHA-256 duplicate cleanup now runs once per car per session, not every open.
  • SyncRepository.syncChargeDetails preloads summaries once instead of a per-charge DB read in the batch loop.
  • RouteSimplifier rewritten iteratively (keep[] bitset + explicit stack) — same output, no per-level list allocation, bounded stack.

Deliberately skipped / deferred (engineering judgment, flagged not silently dropped)

  • Widget progress-bar bitmap cache (Tier B): each 3-min update carries a new battery level, so a cache almost never hits — churn without gain.
  • AppSettings cache (Tier B): negligible gain over the already in-memory-cached DataStore, and a stale server URL/token would silently break API calls.
  • Comparison bounding-box SQL prefilter (Tier C): the off-main move removes the jank; a too-tight SQL box could silently drop valid matches, so it needs a new indexed query + margin care — deferred rather than risk correctness.
  • Baseline Profile (the biggest cold-start lever): not in this PR — it needs a :baselineprofile macrobenchmark module (half-day of scaffolding). Happy to do it as a follow-up. (F-Droid note: the generated baseline-prof.txt ships fine as a source file; only regeneration is local, not in F-Droid CI.)

Verification

  • ./gradlew testDebugUnitTest lintDebug assembleDebug — all green.
  • Installed on the test device.

🤖 Generated with Claude Code

vide and others added 4 commits July 3, 2026 13:02
Tier A — visual-safe runtime wins:
- Swap all 21 collectAsState() → collectAsStateWithLifecycle() so screens
  stop recomposing while backgrounded/covered.
- Dashboard: pause the 5s status poll when the screen isn't STARTED
  (LifecycleStartEffect + resume/pauseAutoRefresh), so it no longer polls
  the network or churns state off-screen.
- StatsRepository.getStats: run off Dispatchers.IO and fan the three
  independent groups (quick/deep/sync) out with async instead of ~50
  sequential DAO queries on the main thread.
- Add contentType to the charges/drives/trips/mileage lists so Compose
  reuses row layout nodes during fling.
- ChargingNotificationWorker: stop refetching the full car list once per
  car (pass the already-fetched CarData into checkCarStatus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arts

Tier B — visual-safe per-frame/allocation wins:
- ChartDrawUtils text helpers (drawYAxisLabels/drawDualYAxisLabels/
  drawTimeLabels) now take a pre-built android.graphics.Paint instead of
  allocating one per draw. OptimizedLineChart/DualAxisLineChart build the
  paints once with remember(color). The 800ms entrance animation redraws
  ~48 frames, so this removes ~3-5 Paint (+Color/String) allocations per
  frame per chart. Same pixels.
- PowerSocOverlayChart: sort each curve's points by SoC once (remember)
  and pre-order curves by base, instead of re-sorting inside the draw loop
  and twice per crosshair-drag frame via interpolatePower. Same curves.
- MileageScreen: memoize the yearly/monthly/daily chart-data derivation
  and the inner BarChartData mapping with remember, and mark BarChartData/
  BarSegment @immutable so InteractiveBarChart can skip.

Deliberately skipped two low-value Tier B items: caching the widget
progress-bar bitmap (updates are 3-min and each carries a new battery
level, so a cache almost never hits) and caching AppSettings in
SettingsDataStore (negligible gain over the already in-memory-cached
DataStore, and a stale server URL/token would silently break API calls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ute simplify

Tier C — data-layer wins (visual-safe; correctness preserved):
- ChargeComparisonRepository/DriveComparisonRepository.findComparable:
  wrap the full-history load + per-row Haversine + sort in
  withContext(Dispatchers.Default) so it no longer runs on the caller's
  main thread. (Room already runs the queries on its own executor.)
- TripRepository.getTrips: run off Dispatchers.Default, and only run the
  beta-era duplicate-cleanup (SHA-256 per saved trip) once per car per
  session instead of on every trip-list open.
- SyncRepository.syncChargeDetails: preload charge summaries once
  (getAllForCar) instead of a per-charge DB read inside the batch loop.
- RouteSimplifier: rewrite Douglas-Peucker iteratively with a keep[]
  bitset + explicit stack — same output, no per-level list allocation,
  bounded stack (was O(n) recursion depth + list rebuild per level).

Deferred (correctness surface): pushing the comparison bounding-box
prefilter into SQL — the Kotlin Haversine already gates matches, but a
too-tight SQL box would silently drop valid comparisons, so it needs a
new indexed query + margin care. Moving the work off-main removes the
actual jank without that risk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vide vide force-pushed the perf/tiers-a-b-c branch from 950718d to ef8ff71 Compare July 4, 2026 10:02
@vide vide merged commit aa97807 into main Jul 4, 2026
2 checks passed
@vide vide deleted the perf/tiers-a-b-c branch July 4, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant